fix(agent-runtime): release an unclosed think-tag open as text at flush - #1173
fix(agent-runtime): release an unclosed think-tag open as text at flush#1173nordicnode wants to merge 3 commits into
Conversation
An explicit <think> open committed the rest of the step to the thinking box unconditionally, so when a model wrote the tag as prose (docs, quoted templates, a broken chat template) the visible answer landed in the thinking box and the user saw an empty or short reply. Give the explicit open the same treatment the implicit head already has: hold until a close settles the block as reasoning, and release the hold as text when the step ends without one — an answer is delayed, never swallowed. The budget stays implicit-head-only: a genuine think block can legitimately run long, and only its close (or flush) settles it. Refs CodebuffAI#1155
|
The instinct here is right: an unclosed The concern is the fix for the common case. Before this change, an explicit That's a real UX/latency tradeoff: trading "swallowed answer on malformed input" for "no live reasoning display on well-formed input." It may be the right tradeoff, but the PR doesn't acknowledge it or measure it, and a maintainer will want to see how this behaves against an actual long-running reasoning model before porting it, or a bound on the hold (chunked flush of confirmed-safe prefixes, say) rather than all-or-nothing at close/flush. Worth resolving before this goes in: either justify why buffering is acceptable for the paired-tag case, or find a way to keep streaming reasoning live while still handling the never-closes tail correctly. |
…_OPEN_HOLD_CHARS so paired traces stream live (review feedback on CodebuffAI#1173)
|
The concern is right —
Honest residual: a prose answer that quotes the tag and runs past 4000 chars before the step ends gets its head committed to the thinking box. That is the same input class the paired-prose case ( Tests added for the long-trace shape the review asked for: Validation: |
Clean lanes (every model that pairs its tags) stream rule-1 blocks live with zero buffering — the review's latency concern. Lanes whose last assistant turn left an open unclosed arm holdExplicitOpens, so a prose-quoted tag delays the answer instead of swallowing it, released as text at the shared IMPLICIT_OPEN_BUDGET_CHARS or at flush. Replaces the commit-past-bound design from 9284bbe, which re-swallowed long prose answers past the bound.
|
Follow-up in 1877b55, superseding the bounded-hold approach from 9284bbe: the bound traded one swallow for another (a prose answer longer than the bound after a quoted open would still commit its head to the thinking box — the exact #1155 class). The redesign keeps your latency case at literally zero cost and fixes the swallow only where it has actually been observed:
Why not the review's "chunked flush of confirmed-safe prefixes": after an open, no prefix is confirmable as text until Validation: |
Summary
<think>open in the content stream no longer commits the rest of the step to the thinking box unconditionally. Resolution is armed from the conversation history, symmetric with the existing implicit-head machinery:streams a long well-formed trace per-delta on a clean lane, never bufferedtest.historyHasUnclosedOpenfinds an open with no close after it in the last assistant turn:holdExplicitOpensholds from the open, a close settles it as reasoning, and the sharedIMPLICIT_OPEN_BUDGET_CHARS(4000) or the end of the step releases it as text, so an answer behind a quoted tag is delayed but never swallowed (Harness: prompt-error orphans the run, unclosed think tag hides the answer, mid-stream 5xx/429 fatal while severed connections recover #1155, bug 2).historyHasUnclosedOpenreads the last assistant message only, so the arm self-heals: a broken-template lane stays armed step after step, while a one-off quoted open arms exactly the next step and clears as soon as a properly paired reply lands — a session-wide scan would hold every later genuine trace because the user once asked about the tag.reasoning_contentchunk still disarms any hold and releases it as text.This addresses the answer-swallowing part of #1155: whenever a model emits the tag as prose — writing docs, quoting a template, or a lane whose chat template is broken — the visible answer landed in the thinking box and the user saw an empty or short reply. History still stores the raw text, so this is a display/reclassification fix, not context loss.
Tradeoffs: the first step on a lane that starts leaving opens unclosed has no history to arm from (same accepted gap
implicitOpenhas; it self-heals on the next step). On an armed lane, a genuine short trace held under budget delays its display until the close arrives, bounded by the 4000-char budget. The review's "chunked flush of confirmed-safe prefixes" is not implementable — after an open, no prefix is confirmable until</think>or EOF (reasoning_deltahas no retraction) — so the paired-vs-prose bet is made once, with history evidence, instead of per-chunk guesses.Validation
bun test packages/agent-runtime/src/util/__tests__/think-tag-stream.test.ts— 33 pass, 0 fail (24 before: +5historyHasUnclosedOpen, +2 clean-lane streaming, armed hold/budget/disarm tests reshaped)mainbaseline (2 pre-existingprompts-schema-handlingfails + 2agents-graveyarderrors — stash-diff proved, timings excluded)tsc --noEmit -p .clean foragent-runtimemodulo those pre-existing errorsbunx prettier --checkclean on all three changed filesRefs #1155
Replaces #1161 — auto-closed when the repo history was force-pushed (the original commits no longer exist in the new history). Rebased onto the new main per the maintainer's request; picking the review up here.